iT邦幫忙

2022 iThome 鐵人賽

DAY 22
0

今日目標,將房間頁面的資訊透過 WebSocket 串接並即時顯示。

Multicast

當某個房間內的相關資訊改變的時候,比如:房主換人、有人進出造成人數改變、有人準備或取消準備,我們預期將該房間的最新資訊傳給所有在那個房間的人,但我們又不希望隔壁房間的也收到這個房間的消息,如果用前面的方法,先訂閱某個代理再將訊息傳給代理會造成所有人都接收到。
也許讀者會想那我就再開一個代理專門給那個房間的人訂閱就好啊?但這是不太可能的,還記得在 Controller 的配置嗎?我們必須提前知道使用者傳訊息給哪個代理,並對其做處理,所以不太可能在執行的時候做出動態的決策。
因此,我們這時候就要使用一個觀念,群播(multicast),但其實在 WebSocket 中,群播的本質是單播(unicast),也就是對單一目標的訊息傳輸,那只要我們對多個使用者個別使用單播發送消息,即可做到群播的功能。

Unicast

在實作之前,我們先補足觀念~~

URL 映射

對 URL 的細部作解釋,其實這邊對於單純想實作的讀者可能幫助不大,因為這邊的細節 Spring Boot 都有相關模組幫忙完成了,讀者根據自己需求決定要不要跳過囉~~ /images/emoticon/emoticon12.gif

  1. 首先,我們期望 Client 訂閱目標為 /user/queue/room-info
  2. 而對於此目標的映射(mapping),在 Server 端會透過 UserDestinationMessageHandler 自動轉換成單一使用者的目標位置(可以想成就是一個獨一無二的地址,對應到特定的使用者),比如某個使用者的名稱為 mark,那在這邊轉換之後會生成 /queue/room-info-mark
  3. 而當 Server 端要發送訊息給特定使用者時,會透過 /user/{username}/queue/room-info 的 URL 作響應

用法

這邊僅簡述怎麼用,以及所需要的參數等。

  1. 在 WebSocket Config 新增代理的前綴 /queue
  2. 在 Spring Boot 使用 simpMessagingTemplate.convertAndSendToUser(),而它的參數分別為「使用者」、「目的 URL」、「訊息」
  3. 在 SockJS 訂閱 /user/queue/room-info

實作功能

再來,我們回到專案開始實作功能~~

  1. 先到 WebSocketBrokerConfig 修改配置,將 configureMessageBroker() 內的 config.enableSimpleBroker 加入另一個前綴 /queue,修改後的內容(片段)為:
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic", "/queue");  // 多加一個前綴 "/queue"
    }
    
  2. 再來到 RoomService 加入新的服務 sendMessageToRoom,加入的片段程式碼為:
    public void sendMessageToRoom(String roomId, String destination, Object message) {
        // 取得指定房間
        Room room = roomList.getRoomById(roomId);
    
        // 取得房間內的所有成員
        ArrayList<String> roomMembers = room.getAllMembers();
    
        // 發送資訊給所有房間內的成員
        for (String member : roomMembers) {
            simpMessagingTemplate.convertAndSendToUser(member, destination, message);
        }
    }
    
  3. 再新增一個服務 sendRoomInfo() 到 RoomService,加入的片段程式碼為:
    public void sendRoomInfo(String roomId) {
        Room room = roomList.getRoomById(roomId);
        ArrayList<String> roomMembers = room.getAllMembers();
    
        // 把使用者是否準備的狀態取出
        Map<String, Boolean> userReadyStatus = new HashMap<>();
        for (String member : roomMembers) {
            userReadyStatus.put(member, this.userStatus.isUserReady(member));
        }
        Map<String, Object> response = new HashMap<>();
        response.put("userStatus", userReadyStatus);
    
        // 取得房間資訊
        response.put("roomInfo", room.getInfo());
    
        // 發送給該房間的所有人
        sendMessageToRoom(roomId, "/queue/room-info", response);
    }
    
  4. 修改昨天建立的 room.js,完整內容為:
    var websocket = new WebSocket();
    var myUsername = $("#my-username").val();
    var roomId;
    var owner;
    
    websocket.connect('/connect', () => {
        subscribeRoomInfo();
        websocket.send(`/room-info`, {});
    });
    
    
    function subscribeRoomInfo() {
        websocket.subscribe("/user/queue/room-info", (response) => {
            response = JSON.parse(response.body);
            let roomInfo = response.roomInfo;
            owner = roomInfo.owner;
            roomId = roomInfo.roomId;
            $("#room-id").text(roomId);
    
            // 取得所有成員,如果自己並不在這之中,就跳轉到 rooms
            let roomMembers = [owner, ...roomInfo.guests]
            if (!roomMembers.includes(myUsername)) {
                window.location.href = `/rooms/`;
            }
    
            // 顯示房間內的成員
            let userStatus = response.userStatus;
            $("#room .card").each((index, element) => {
                let name = roomMembers[index];
                if (name === myUsername) {
                    $(element).addClass("shadow rounded");
                }
    
                // 如果自己是房主,那其他成員右上方會出現 X 的按鈕,用於把成員踢出去
                let closeButton = "";
                if (owner === myUsername  && name !== myUsername) {
                    closeButton = `
                    <button type="button" class="close close-button" onclick="quitRoom('${name}')">
                        <i class="bi bi-x-circle-fill"></i>
                    </button>
                    `;
                }
    
                // 如果已經準備,就顯示「準備」,房主就改成顯示「房主」
                let readyText = "";
                if (userStatus[name]) {
                    readyText = "準備";
                }
                if (name === owner) {
                    readyText = "房主";
                }
    
                // 顯示玩家名稱、玩家頭貼(目前大家都一樣,還沒特別處理~~)
                $(element).prop("id", `user-${name}`);
                $(element).empty();
                if (index < roomMembers.length) {
                    $(element).html(`
                    ${closeButton}
                    <img src="https://picsum.photos/200/200" class="card-img-top" alt="...">
                    <div class="card-body">
                        <h5 class="card-title">${name}</h5>
                        <h4 class="card-body user-ready">${readyText}</h4>
                    </div>
                    `);
                }
                else {
                    $(element).html(`
                    <div class="card-body">
                        <h5 class="card-title"></h5>
                    </div>
                    `);
                }
            })
    
            // 如果自己是房主,改變準備按鈕的文字為「開始」
            $("#ready").text(owner === myUsername ? '開始' : '準備');
        })
    }
    
  5. 在 RoomWsController 新增,加入的片段程式碼為:
    @MessageMapping("/room-info")
    public void getRoomInfo(Principal principal) {
        String username = principal.getName();
        String roomId = this.userStatus.getUserRoomId(username);
        this.roomService.sendRoomInfo(roomId);
    }
    
  6. 再來就去頁面測試吧,先創建房間,就會看到自己的資訊了,然後再用另一個瀏覽器加入房間,就會看到房間內的資訊即時更新了~~
    (ps. 目前準備、踢出房間、退出房間的功能還沒寫,點下去沒反應是正常的!!)
    /images/emoticon/emoticon07.gif

參考資料


本來預計今天還要實作「準備」的功能,但寫到這發現內容還挺多的,所以就明天再寫吧~~
/images/emoticon/emoticon39.gif


上一篇
Day 20 - 房間頁面
下一篇
Day 22 - 準備
系列文
Spring Boot... 深不可測31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言